Conversation
❌ Deploy Preview for gr-sentinel failed. Why did it fail? →
|
❌ Deploy Preview for gr-sentinel failed. Why did it fail? →
|
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
RequireAuth wraps the AppShell route subtree. Without a stored session it Navigate-redirects to /auth/login while preserving the intended location in state.from so LoginPage can route the user back after sign-in. Bare /, /applications, /groups, etc. now bounce to login. api.ts gains two interceptors: - request: attaches Authorization: Bearer <accessToken> from the stored session when one exists. - response: on 401, calls /auth/refresh once (coalesced across concurrent in-flight requests via a shared promise), saves the new pair, and retries the original request. If refresh itself fails it clears the session and force-navigates to /auth/login. The refresh request URL is exempt so we don't loop. LoginPage reads state.from?.pathname (set by RequireAuth) and routes back there on success; defaults to / when arriving fresh or from onboarding (?email=...).
…n header
Adds TanStack Query as the data layer. main.tsx wraps the app in a
QueryClientProvider with retry: 1 and refetchOnWindowFocus: false.
lib/auth.ts grows from storage helpers to also export an Entity type
(mirrors core's response shape) and a useAuth() hook that:
- reads the local session synchronously
- queries GET /core/entity/<entityId> via useQuery, keyed by entityId,
5min staleTime, only enabled when a session exists
- exposes { session, user, isLoading, isAuthenticated, refresh, logout }
logout() clears the session, drops the query cache, and force-navigates
to /auth/login. refresh() invalidates the currentEntity query so
mutations (e.g. settings edits, future) can call it and have all
consumers re-render.
AppHeader is the first consumer: drops mockUser and renders a
Skeleton avatar while loading, then the real name / email / avatar.
Sign-out wired to logout().
Adds the two public entity endpoints that frontends and authenticated third-party clients should use, replacing direct hits to /core/entity/:id (which is now strictly service-to-service). Both handlers gate via the existing AuthChecker + Require() idiom: - GetMe (/entities/@me) requires user:read scope; resolves the entity from the bearer's subject claim. - GetEntity (/entities/:id) requires user:read AND the URL :id must match the bearer's entity_id. Self-only until shared-group / admin scope authz lands. Two new accessors in api.go bottom: GetRequestTokenEntityID, RequestTokenHasEntityID, RequestTokenExists. Rincon route prefix /entities/** registered for core, kerbecs gets /api/entities/* upstream. Web's useAuth swaps from GET /core/entity/:id to GET /entities/@me — no longer needs to know its own entity_id at call time (the bearer is the identity).
…ntities/:id Third-party apps still gated on user:read scope and matching entity_id; first-party tokens (audience sentinel) skip the id check so team directory / cross-entity reads from the Sentinel web app work without a dedicated admin scope. When a real cross-user scope (eg users:read) is needed for non-sentinel callers, add it to the Any() chain.
Reads the first_name from the live entity (via useAuth) instead of mockUser, with a skeleton placeholder while the query is in flight. Recently-accessed and recent-activity sections still mock — they need a per-user logins endpoint (we dropped /entities/@me/logins earlier) and a way to fetch app metadata for each distinct client_id in the user's history.
Cloudflare sets CF-Connecting-IP server-side (unspoofable as long as the origin only accepts traffic from CF's IP ranges) so we can rely on it for the real client IP without configuring trusted proxies in gin. Falls back to c.ClientIP() in dev where no CF is in front. Helper lives at the bottom of oauth/api/api.go; all three entity-login recording sites (login, refresh, OAuth code exchange) swap over.
Public endpoint exposing the user's session history. Filters via query params: client_id match one application's logins (eg sentinel, blix) scope exact-match scope string before RFC3339 cutoff, returns rows created before this time after RFC3339 cutoff, returns rows created after this time limit integer cap, default unlimited service.GetEntityLogins refactored to take an EntityLoginsFilter struct so the parameter list doesn't grow unbounded; existing /core/entity/:entityID/logins caller updated. Authz follows the /entities/:id pattern: aud=sentinel (first-party app) bypasses self-only; third-party tokens need user:read scope AND must match the bearer's user_id claim. Two new helpers in api.go bottom: GetRequestTokenUserID, RequestTokenHasUserID.
Replaces mockRecentLogins with a useQuery against
GET /users/{user_id}/logins?limit=5, keyed off the user id from
useAuth(). Skeleton rows while in flight, empty-state copy when there
are no logins, then real rows showing the client_id, scope, ip_address
and created_at.
Recently-accessed apps still mocked — needs per-app-metadata fanout
(dedupe client_ids in logins + bulk fetch /applications/client/:id),
which is a separate piece of work.
After logins resolve, dedupe client_ids and fire one GET /applications/client/<cid> per unique value (cached 5min). Display becomes 'client_id · App Name' with the name in muted text when the fetch succeeds; falls back to just client_id otherwise. Uses useQueries so all the per-app fetches happen in parallel and share the React Query cache with anywhere else we might fetch the same app.
…tly Accessed
Per-user 'apps signed into, ordered by most-recent-access' aggregated
server-side via DISTINCT-ON-equivalent (GROUP BY client_id with MAX
created_at) joined to the application table. Single query returns
denormalized {Application, last_accessed_at} so the frontend skips the
client-side dedupe + per-app fanout that would have missed apps with
lopsided login distributions (50 sentinel logins drowning out one Blix
login last week).
service.GetAccessedApplicationsForEntity(entityID, limit)
returns []AccessedApplication{model.Application, last_accessed_at}
GET /api/users/:id/applications?limit=N
same authz as /users/:id/logins (aud=sentinel bypass OR
user:read + matching user_id)
HomePage swaps mockApplications for a useQuery against the new endpoint.
Skeleton tiles while loading, friendly empty copy when there are no
logins yet, real AppCard grid otherwise.
…tions Disambiguates from a future /users/:id/applications endpoint that would list apps the user owns (registered as OAuth clients). The frontend useQuery key, handler name, and route all rename in lockstep.
Distinct from redirect_uris (OAuth callbacks) — this is where to send
the user to actually open the app, used by the dashboard's Recently
Accessed tiles and anywhere else we surface a clickable app.
model.Application gains LaunchURL string
init job sets the Sentinel app's launch_url to
https://sso.gauchoracing.com on first create
HomePage AccessedApplication response type adds launch_url, maps to
the Application.url field the AppCard renders
Existing rows: AutoMigrate adds the column nullable; backfill the
Sentinel row in dev manually if needed. When other apps are registered
they should set launch_url at create time.
Previously rsa.GenerateKey was called in InitializeKeys every time core
booted, so every air-triggered rebuild invalidated every active session.
Users were getting logged out on every code change.
Adds model.SigningKey with Active boolean. InitializeKeys now:
- loads the active key from signing_key table if one exists
- otherwise generates a fresh keypair, PEM-encodes it, persists with
Active=true, and uses it
The Active flag is unused today (always one active key) but is the
schema we need for rotation: mint a new active key, mark the old one
inactive, expose both public halves via JWKS until old tokens age out.
That work adds a kid header to JWTs and per-kid lookup in ValidateToken
but doesn't require a migration.
Sessions now survive core restarts cleanly.
Replaces the 'Coming soon' stub on /applications with a real listing backed by GET /applications. Search input filters client-side across name/description/client_id; results sort alphabetically. Loading skeletons, empty-state copy for both 'no apps registered' and 'no matches'. Pulls AppCard out of HomePage into components/AppCard.tsx so both pages share it. The shared card accepts the API Application shape directly (snake_case fields, launch_url for the link, icon_url with fallback to the gradient + initial). Optional lastAccessedAt prop renders the 'Last accessed Xh ago' footer only on the dashboard's Recently Accessed section. New lib/applications.ts holds the Application TS type mirroring the core JSON shape — same pattern as lib/auth.ts's Entity type.
Clicking a tile (dashboard Recently Accessed or full Applications grid)
now opens /applications/:id rather than launching the external app in a
new tab. The details page is where the user can see the app's metadata
and explicitly launch from there.
components/AppCard becomes a <Link to=...>, chevron icon on hover
pages/applications/ApplicationDetailsPage.tsx
- useQuery GET /applications/:id
- skeleton while loading, "not found" on miss
- header with icon, name, description, Launch outline button
- rows: client_id, launch URL, redirect URIs, registered date
…d vs browse behavior
Two click affordances are different concerns:
- Dashboard (Recently Accessed) — user wants to jump back into the app
they were using. LaunchAppCard opens launch_url in a new tab with
the external-link icon as the hover affordance.
- Applications page — user wants to inspect details (client_id,
redirect URIs, last-launched, etc.) before launching. AppCard
navigates to /applications/:id with a chevron-right hover icon.
Leaves room for both to diverge as we add more variant-specific UI
(launch counts on the dashboard card, ownership badges on the browse
card, etc.) without one component swelling into a config soup.
Backend
Splits CreateOrUpdateApplication into:
POST /applications — create, response includes client_secret once
PUT /applications/:id — update name/description/icon_url/launch_url
Create handler builds a createdApplicationResponse that embeds
model.Application and adds a separate Secret field (json:client_secret)
since model.Application JSON-skips client_secret on subsequent reads.
Web
/applications/new ApplicationNewPage — form, on success shows
client_id + client_secret in a dialog with
copy-to-clipboard buttons before routing to
the new app's details page
/applications/:id/edit ApplicationEditPage — fetches existing, prefills
form, PUTs the edits, invalidates the index +
details queries
/applications gains "New application" CTA in the header
/applications/:id gains "Edit" button next to "Launch"
Redirect URI management is still TODO — we have add/remove endpoints
on the backend but no UI for them on the edit page yet.
Adds GET /applications/:id/secret (gated on aud=sentinel — first-party only) so the secret can be retrieved on demand without leaking through every app read. model.Application keeps json:- on ClientSecret so list and by-id reads stay secretless. Details page gains a Client Secret row with masked dots, an eye toggle that triggers the secret fetch (useQuery enabled on toggle, 5min cache), and a copy button. Client ID row gains a copy button to match. Authz tightens later when we have application ownership — at that point the gate becomes ownership OR sentinel:all instead of aud=sentinel.
Details page now reads as three cards instead of one bordered row stack: - OAuth credentials (Client ID + masked secret with reveal/copy) - Redirect URIs (list with copy buttons, link to edit when empty) - Metadata (Launch URL, Registered, Last updated) Header gets a wrap-friendly layout so the action buttons reflow on narrow screens. Edit page becomes two cards: - Basic info (name, description, launch_url, icon_url + save) - Redirect URIs (list with X-to-remove, inline form to add) Redirect changes fire POST/DELETE /applications/:id/redirect-uris and invalidate the by-id query so both edit and details refresh. Adds a CopyableMono helper for the mono-font value + copy-button rows that appear in three places now (client_id, secret, each redirect URI).
Backend CreateApplication now Requires a bearer and sets OwnerID from GetRequestTokenEntityID(c). The init job sets the bootstrap Sentinel app's OwnerID to the Sentinel core entity so it isn't orphaned. Web Application TS type already had owner_id; Entity type gains the service_account variant (mirror of model.Entity.ServiceAccount, also omitempty server-side). Details page Metadata card adds a "Created by" row that fetches GET /entities/<owner_id> via useQuery (5min cache) and renders the user's full name, or the service account name when the owner is a service entity. Existing rows in dev: backfill the Sentinel app manually with UPDATE application SET owner_id = '<sentinel_core_entity_id>' WHERE id = '<sentinel_app_id>' AND owner_id = ''
Reads (GET /applications, /applications/:id, /applications/:id/groups,
/applications/:id/redirect-uris):
Require aud=sentinel OR applications:read scope
Writes (POST /applications, PUT /:id, DELETE /:id, group + redirect-uri
mutations):
ApplicationWriteAuthorized helper:
sentinel:all
OR aud=sentinel AND bearer's entity_id == app.owner_id
OR applications:write scope AND bearer's entity_id == app.owner_id
Loaded application is fetched before the gate runs so the owner
comparison has data.
Secrets (GET /applications/:id/secret):
Stricter than reads — sentinel:all OR aud=sentinel + owner only.
A third-party app with applications:read can't get any app's secret.
Create (POST /applications):
aud=sentinel OR applications:write. No owner check (no app yet).
GET /applications/client/:clientID stays ungated — oauth's authorize
flow hits it over the docker network with no bearer. When we add
service-to-service client_credentials tokens, this can be locked down
behind sentinel:all.
ApplicationWriteAuthorized helper added at the bottom of application.go.
The dialog was a one-time-show of the freshly minted secret. Now that the details page has an eye-toggle reveal that fetches on demand, the 'save this now' semantics are gone — the secret is retrievable anytime by the owner. Submitting just toasts 'Application created' and routes to the details page. Drop CreatedApplication response type + Dialog imports + the secret/id copy buttons + the dismiss-to-navigate dance.
The response interceptor was treating every 401 as 'your bearer expired, try refreshing' — including the 401 from a failed login. With no session in localStorage, the refresh branch fell through to window.location.href = '/auth/login', a hard reload that wiped the controlled-input state and skipped LoginPage's catch + toast. Exempt /auth/login and /auth/refresh from the refresh-retry path so their 401s propagate directly to the caller. Also drop the clearSession+force-redirect when there's no refreshToken at all — just propagate; RequireAuth catches the un-authed case on its own.
Both oauth and discord wrapped every sentinel.* call's failure into a
single 'POST /foo returned 401' string. The login handler then
collapsed *any* error into 'invalid credentials' — masking
'rincon couldn't resolve the route' and 'core was unreachable' as if
the user typed the wrong password.
New sentinel.APIError exposes:
Method, Route for context in error strings
Status 0 when no HTTP response was received (rincon
resolution failure, transport error); the real
status code otherwise
Body, Message raw body and the parsed {"error": "..."} field
Err underlying transport/resolution error, Unwrap-able
Centralized the request flow into a single do() so Get/Post/Put/Patch/
Delete are thin wrappers around it.
oauth's LoginEmailPassword now errors.As checks the APIError: 401 from
core surfaces as 401 'invalid credentials' to the user; anything else
surfaces as 502 'auth service unavailable' with full upstream detail
logged. Callers in the rest of the codebase still get a plain error
back via the interface — only the ones that care about distinguishing
upstream status pull out APIError.
Same shape mirrored in discord/pkg/sentinel.
…al cause Previously APIError.Status == 0 collapsed two distinct failure modes: 'rincon couldn't resolve the route' (no upstream registered) and 'reached rincon, but the transport call to the resolved service died.' Login surfaced both as 'core service is unavailable' even though only the second is literally true. Adds two sentinel-side error sentinels: ErrRinconUninitialized rincon client never connected ErrRouteResolution rincon doesn't know who serves this route resolveURL wraps the right one. errors.Is in the login handler now picks the right user-facing message: invalid credentials 401 from core service registry not initialized rincon client never came up core route not registered ... rincon doesn't know about core core service is unreachable everything else (transport, 5xx) Same shape mirrored in discord/pkg/sentinel for when discord starts hitting more core endpoints (consume orchestration etc.).
…gorizing APIError.Error() already builds a descriptive string with method, route, status (or underlying cause when no HTTP response). The login handler's switch was redundant — anything that isn't 'core said 401' just bubbles the sentinel error verbatim. POST /core/login/email-password: rincon could not resolve route: ... POST /core/login/email-password: rincon client not initialized POST /core/login/email-password returned 500 POST /core/login/email-password: dial tcp ...: connection refused ErrRinconUninitialized and ErrRouteResolution stay in the pkg for any future caller that wants to errors.Is on them.
OrbStack injects HTTP_PROXY=http://proxyproxy.orb.internal:8305 into every container. Go's net/http honors it, so container-to-container calls (notably to rincon:10311 for service registration and route resolution) get routed through the host proxy, which can't resolve docker-internal service names and returns 502. Result: silent rincon registration failures and broken inter-service routing. Neutralized via a shared YAML anchor merged into each service's environment.
…ll services" This reverts commit 67b71e6.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Sentinel v5 rewrite, see design doc here for more info.